Hola 😀
Soy Hesus Garcia como "Jesús" pero con H. Sé que puede ser confuso al principio, pero una vez que lo recuerdes, ¡nunca lo olvidarás! 🌝 . Como revisor de código de Practicum, estoy emocionado de examinar tus proyectos y ayudarte a mejorar tus habilidades en programación. si has cometido algún error, no te preocupes, pues ¡estoy aquí para ayudarte a corregirlo y hacer que tu código brille! 🌟. Si encuentro algún detalle en tu código, te lo señalaré para que lo corrijas, ya que mi objetivo es ayudarte a prepararte para un ambiente de trabajo real, donde el líder de tu equipo actuaría de la misma manera. Si no puedes solucionar el problema, te proporcionaré más información en la próxima oportunidad. Cuando encuentres un comentario, por favor, no los muevas, no los modifiques ni los borres.
Revisaré cuidadosamente todas las implementaciones que has realizado para cumplir con los requisitos y te proporcionaré mis comentarios de la siguiente manera:
Puedes responderme de esta forma:
¡Empecemos! 🚀
Telecomunicaciones: Identificar operadores ineficientes¶
El objetivo de este análisis es determinar cuando un operador es ineficiente en sus labores. Estaremos importando dos tablas: La primera con la información de las llamadas recibidas e información del operador que recibió la llamada.
Los campos que contiene esta primera tabla son los siguientes:
user_id: ID de la cuenta de clientedate: fecha en la que se recuperaron las estadísticasdirection: "dirección" de llamada (outpara saliente,inpara entrante)internal: si la llamada fue interna (entre los operadores de un cliente o clienta)operator_id: identificador del operadoris_missed_call: si fue una llamada perdidacalls_count: número de llamadascall_duration: duración de la llamada (sin incluir el tiempo de espera)total_call_duration: duración de la llamada (incluido el tiempo de espera)
La segunda tabla que estaremos importando contiene la información de los clientes y se organiza de la siguiente manera:
user_id: ID de usuario/atariff_plan: tarifa actual de la clienteladate_start: fecha de registro de la clientela
Las métricas para evaluar si un operador es eficiente en sus funciones serán tres:
- Una proporción de llamadas perdidas altas
- Un tiempo de espera muy alto antes de atender una llamada
- Pocas llamadas salientes para operadores que cuyas funciones incluyan llamar a clientes
Luego de determinar estas tres métricas, procederemos a análizar hipótesis con base en las conclusiones anteriores.
Importando datos¶
Importaremos las librerías necesarias para nuestros análisis, para posteriormente, importar 2 tablas que contienen los datos necesarios.
La primera tabla, ubicada en la ruta /datasets/telecom_dataset_us.csv contiene los datos sobre el desempeño de los operadores que están siendo evaluados.
La segunda tabla, ubicada en la ruta /datasets/telecom_clients_us.csv contiene los datos de los clientes que realizan llamadas al centro de llamadas donde están ubicados los operadores del análisis.
# importando librerías
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import plotly.graph_objs as go
from scipy import stats as st
from statistics import mode
Es preferible agrupar los imports siguiendo el siguiente orden:
Imports de la biblioteca estándar de Python.
Imports de bibliotecas de terceros relacionadas.
Imports específicos de la aplicación local o biblioteca personalizada.
Para mejorar la legibilidad del código, también es recomendable dejar una línea en blanco entre cada grupo de imports, pero solo un import por línea.
Te dejo esta referencia con ejemplos:
https://pep8.org/#imports
# Creando variable para declarar la ruta de los archivos
path = "/datasets/"
# os.path.join()
# importando tablas
calls = pd.read_csv(path+"telecom_dataset_us.csv")
clients = pd.read_csv(path+"telecom_clients_us.csv")
Al utilizar os.path.join(), podemos crear rutas de archivos que sean independientes de la plataforma, lo que significa que funcionarán tanto en sistemas Windows como en sistemas basados en Unix. Esto se debe a que os.path.join() automáticamente utiliza el separador de ruta adecuado (\ en Windows y / en sistemas basados en Unix) para unir los componentes de la ruta.
Conclusión
Una vez importados nuestros datos procederemos a realizar la exploración de los datos nuestras tablas.
Llamadas
# Mostrando información general de la tabla
calls.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'> RangeIndex: 53902 entries, 0 to 53901 Data columns (total 9 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 53902 non-null int64 1 date 53902 non-null object 2 direction 53902 non-null object 3 internal 53785 non-null object 4 operator_id 45730 non-null float64 5 is_missed_call 53902 non-null bool 6 calls_count 53902 non-null int64 7 call_duration 53902 non-null int64 8 total_call_duration 53902 non-null int64 dtypes: bool(1), float64(1), int64(4), object(3) memory usage: 11.1 MB
# Mostrando primeras 15 filas de la tabla
calls.head(15)
| user_id | date | direction | internal | operator_id | is_missed_call | calls_count | call_duration | total_call_duration | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 166377 | 2019-08-04 00:00:00+03:00 | in | False | NaN | True | 2 | 0 | 4 |
| 1 | 166377 | 2019-08-05 00:00:00+03:00 | out | True | 880022.0 | True | 3 | 0 | 5 |
| 2 | 166377 | 2019-08-05 00:00:00+03:00 | out | True | 880020.0 | True | 1 | 0 | 1 |
| 3 | 166377 | 2019-08-05 00:00:00+03:00 | out | True | 880020.0 | False | 1 | 10 | 18 |
| 4 | 166377 | 2019-08-05 00:00:00+03:00 | out | False | 880022.0 | True | 3 | 0 | 25 |
| 5 | 166377 | 2019-08-05 00:00:00+03:00 | out | False | 880020.0 | False | 2 | 3 | 29 |
| 6 | 166377 | 2019-08-05 00:00:00+03:00 | out | False | 880020.0 | True | 8 | 0 | 50 |
| 7 | 166377 | 2019-08-05 00:00:00+03:00 | in | False | NaN | True | 6 | 0 | 35 |
| 8 | 166377 | 2019-08-05 00:00:00+03:00 | out | False | 880020.0 | True | 8 | 0 | 50 |
| 9 | 166377 | 2019-08-06 00:00:00+03:00 | in | False | NaN | True | 4 | 0 | 62 |
| 10 | 166377 | 2019-08-06 00:00:00+03:00 | out | False | 881278.0 | True | 3 | 0 | 29 |
| 11 | 166377 | 2019-08-06 00:00:00+03:00 | out | False | 880020.0 | True | 5 | 0 | 70 |
| 12 | 166377 | 2019-08-06 00:00:00+03:00 | out | True | 881278.0 | True | 2 | 0 | 5 |
| 13 | 166377 | 2019-08-06 00:00:00+03:00 | out | False | 880020.0 | False | 5 | 800 | 819 |
| 14 | 166377 | 2019-08-07 00:00:00+03:00 | out | False | 880028.0 | True | 1 | 0 | 15 |
# Mostrando últimas 15 filas de la tabla
calls.tail(15)
| user_id | date | direction | internal | operator_id | is_missed_call | calls_count | call_duration | total_call_duration | |
|---|---|---|---|---|---|---|---|---|---|
| 53887 | 168603 | 2019-11-21 00:00:00+03:00 | out | False | 959118.0 | False | 5 | 338 | 423 |
| 53888 | 168603 | 2019-11-27 00:00:00+03:00 | out | False | 959118.0 | False | 1 | 76 | 99 |
| 53889 | 168603 | 2019-11-28 00:00:00+03:00 | in | False | NaN | True | 1 | 0 | 30 |
| 53890 | 168606 | 2019-11-08 00:00:00+03:00 | out | False | 957922.0 | True | 2 | 0 | 40 |
| 53891 | 168606 | 2019-11-08 00:00:00+03:00 | in | False | 957922.0 | True | 1 | 0 | 7 |
| 53892 | 168606 | 2019-11-08 00:00:00+03:00 | out | False | 957922.0 | False | 2 | 255 | 328 |
| 53893 | 168606 | 2019-11-08 00:00:00+03:00 | in | False | NaN | True | 6 | 0 | 121 |
| 53894 | 168606 | 2019-11-08 00:00:00+03:00 | in | False | 957922.0 | False | 2 | 686 | 705 |
| 53895 | 168606 | 2019-11-09 00:00:00+03:00 | out | False | 957922.0 | False | 4 | 551 | 593 |
| 53896 | 168606 | 2019-11-10 00:00:00+03:00 | out | True | 957922.0 | False | 1 | 0 | 25 |
| 53897 | 168606 | 2019-11-10 00:00:00+03:00 | out | True | 957922.0 | True | 1 | 0 | 38 |
| 53898 | 168606 | 2019-11-11 00:00:00+03:00 | out | True | 957922.0 | False | 2 | 479 | 501 |
| 53899 | 168606 | 2019-11-15 00:00:00+03:00 | out | True | 957922.0 | False | 4 | 3130 | 3190 |
| 53900 | 168606 | 2019-11-15 00:00:00+03:00 | out | True | 957922.0 | False | 4 | 3130 | 3190 |
| 53901 | 168606 | 2019-11-19 00:00:00+03:00 | in | False | NaN | True | 2 | 0 | 64 |
# Mostrando distribución general de los datos.
calls.describe()
| user_id | operator_id | calls_count | call_duration | total_call_duration | |
|---|---|---|---|---|---|
| count | 53902.000000 | 45730.000000 | 53902.000000 | 53902.000000 | 53902.000000 |
| mean | 167295.344477 | 916535.993002 | 16.451245 | 866.684427 | 1157.133297 |
| std | 598.883775 | 21254.123136 | 62.917170 | 3731.791202 | 4403.468763 |
| min | 166377.000000 | 879896.000000 | 1.000000 | 0.000000 | 0.000000 |
| 25% | 166782.000000 | 900788.000000 | 1.000000 | 0.000000 | 47.000000 |
| 50% | 167162.000000 | 913938.000000 | 4.000000 | 38.000000 | 210.000000 |
| 75% | 167819.000000 | 937708.000000 | 12.000000 | 572.000000 | 902.000000 |
| max | 168606.000000 | 973286.000000 | 4817.000000 | 144395.000000 | 166155.000000 |
# Buscando valores nulos
calls.isna().sum()
user_id 0 date 0 direction 0 internal 117 operator_id 8172 is_missed_call 0 calls_count 0 call_duration 0 total_call_duration 0 dtype: int64
# Buscando duplicados
calls.duplicated().sum()
4900
# Calculando porcentaje de duplicados en los datos
calls.duplicated().sum() / len(calls)
0.09090571778412675
Conclusión intermedia
La tabla de llamadas cuenta con 53902 registros, de los cuales tenemos 4900 duplicados que representan un 9% de los datos.
Adicionalmente, tenemos 2 columnas con valores nulos, la columna que nos indica si una llamada fue o no interna, y la columna que identifica al operador que recibe o hace la llamada.
También debemos considerar que hay que corregir los datos de la columna de "fecha", y la columna de "internal", esta última luego de verificar la naturaleza de los valores nulos.
Llamadas
# Mostrando información general de la tabla
clients.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'> RangeIndex: 732 entries, 0 to 731 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 732 non-null int64 1 tariff_plan 732 non-null object 2 date_start 732 non-null object dtypes: int64(1), object(2) memory usage: 95.2 KB
# Mostrando primeras 15 filas de la tabla
clients.head(15)
| user_id | tariff_plan | date_start | |
|---|---|---|---|
| 0 | 166713 | A | 2019-08-15 |
| 1 | 166901 | A | 2019-08-23 |
| 2 | 168527 | A | 2019-10-29 |
| 3 | 167097 | A | 2019-09-01 |
| 4 | 168193 | A | 2019-10-16 |
| 5 | 167764 | A | 2019-09-30 |
| 6 | 167050 | A | 2019-08-29 |
| 7 | 168252 | A | 2019-10-17 |
| 8 | 168495 | A | 2019-10-28 |
| 9 | 167879 | A | 2019-10-03 |
| 10 | 168573 | A | 2019-10-31 |
| 11 | 166610 | A | 2019-08-12 |
| 12 | 167536 | A | 2019-09-19 |
| 13 | 168354 | A | 2019-10-23 |
| 14 | 168284 | A | 2019-10-20 |
# Mostrando últimas 15 filas de la tabla
clients.tail(15)
| user_id | tariff_plan | date_start | |
|---|---|---|---|
| 717 | 167415 | B | 2019-09-16 |
| 718 | 166941 | B | 2019-08-26 |
| 719 | 166705 | B | 2019-08-15 |
| 720 | 166587 | B | 2019-08-09 |
| 721 | 167452 | B | 2019-09-17 |
| 722 | 166797 | B | 2019-08-20 |
| 723 | 167268 | B | 2019-09-10 |
| 724 | 166522 | B | 2019-08-07 |
| 725 | 166815 | B | 2019-08-20 |
| 726 | 166702 | B | 2019-08-15 |
| 727 | 166554 | B | 2019-08-08 |
| 728 | 166911 | B | 2019-08-23 |
| 729 | 167012 | B | 2019-08-28 |
| 730 | 166867 | B | 2019-08-22 |
| 731 | 166565 | B | 2019-08-08 |
# Mostrando distribución general de los datos.
clients.describe(include='all')
| user_id | tariff_plan | date_start | |
|---|---|---|---|
| count | 732.000000 | 732 | 732 |
| unique | NaN | 3 | 73 |
| top | NaN | C | 2019-09-24 |
| freq | NaN | 395 | 24 |
| mean | 167431.927596 | NaN | NaN |
| std | 633.810383 | NaN | NaN |
| min | 166373.000000 | NaN | NaN |
| 25% | 166900.750000 | NaN | NaN |
| 50% | 167432.000000 | NaN | NaN |
| 75% | 167973.000000 | NaN | NaN |
| max | 168606.000000 | NaN | NaN |
# Buscando valores nulos
clients.isna().sum()
user_id 0 tariff_plan 0 date_start 0 dtype: int64
# Buscando duplicados
clients.duplicated().sum()
0
# Calculando porcentaje de duplicados en los datos
clients.duplicated().sum() / len(clients)
0.0
Conclusión intermedia
La tabla de clientes se importó correctamente, solo tendríamos que cambiar el tipo de datos de la columna date_start a "datetime".
Internal
# Creando lista de nombre de columnas para iterar sobre ellas
categorical_c = ['direction', 'internal', 'is_missed_call']
# Iterando sobre las columnas categóricas para determinar qué variable está relacionada con estos valores nulos
for column in categorical_c:
print(f"""
Comparando en columna \033[1m{column}\033[0m
{calls[column].value_counts(normalize=True)}
\033[1m{column} sin valores ausentes\033[0m
{calls.dropna(subset=['internal'])[column].value_counts(normalize=True)}
""")
Comparando en columna direction out 0.59213 in 0.40787 Name: direction, dtype: float64 direction sin valores ausentes out 0.593381 in 0.406619 Name: direction, dtype: float64 Comparando en columna internal False 0.885396 True 0.114604 Name: internal, dtype: float64 internal sin valores ausentes False 0.885396 True 0.114604 Name: internal, dtype: float64 Comparando en columna is_missed_call False 0.562762 True 0.437238 Name: is_missed_call, dtype: float64 is_missed_call sin valores ausentes False 0.562889 True 0.437111 Name: is_missed_call, dtype: float64
# Calculando proporción de valores nulos en la columna internal
calls['internal'].isna().sum() / len(calls)
0.0021706059144373123
Conclusión intermedia
Los valores nulos de la variable se distribuye de forma muy parecida entre las variables categóricas, considerando que estamos tratado con el 0.2% de los registros en la tabla, eliminaremos los mismos.
# Eliminando filas con valores nulos en la columna internal
calls = (calls
.dropna(subset=['internal'])
.reset_index(drop=True)
)
# Comprobando cantidad de valores nulos
calls.isna().sum()
user_id 0 date 0 direction 0 internal 0 operator_id 8115 is_missed_call 0 calls_count 0 call_duration 0 total_call_duration 0 dtype: int64
Conclusión intermedia
Eliminamos las filas con valores nulos en la columna internal debido a que es una variable clave para responder a las preguntas de nuestro análisis.
Operator_id
# Iterando sobre las columnas categóricas para determinar qué variable está relacionada con estos valores nulos
for column in categorical_c:
print(f"""
Comparando en columna \033[1m{column}\033[0m
{calls[column].value_counts(normalize=True)}
\033[1m{column} sin valores ausentes\033[0m
{calls.dropna(subset=['operator_id'])[column].value_counts(normalize=True)}
""")
Comparando en columna direction out 0.593381 in 0.406619 Name: direction, dtype: float64 direction sin valores ausentes out 0.694482 in 0.305518 Name: direction, dtype: float64 Comparando en columna internal False 0.885396 True 0.114604 Name: internal, dtype: float64 internal sin valores ausentes False 0.872805 True 0.127195 Name: internal, dtype: float64 Comparando en columna is_missed_call False 0.562889 True 0.437111 Name: is_missed_call, dtype: float64 is_missed_call sin valores ausentes False 0.660236 True 0.339764 Name: is_missed_call, dtype: float64
# Calculando proporción de valores nulos en la columna internal
calls['operator_id'].isna().sum() / len(calls)
0.15087849772241332
Conclusión intermedia
Dada la naturaleza de la variable y la importancia que tiene para nuestro análisis, completaremos los valores ausentes con el string unknown e informaremos al equipo encargado de recaudar los datos para evitar que ocurra más adelante.
Estaremos completando el 15.08% de los valores ausentes como unknown.
# Completando valores ausentes
calls['operator_id'].fillna('unknown', inplace=True)
# Calculando cantidad de valores nulos
calls.isna().sum()
user_id 0 date 0 direction 0 internal 0 operator_id 0 is_missed_call 0 calls_count 0 call_duration 0 total_call_duration 0 dtype: int64
# Eliminando duplicados implícitos
calls = calls.drop_duplicates().reset_index(drop=True)
# Comprobando la presencia de duplicados
calls.duplicated().sum()
0
# Comprobando nuevo tamaño de la tabla de llamadas
calls.shape[0]
48892
Conclusión intermedia
Eliminamos los duplicados implícitos, estos a su vez representaban el 9% de los datos. Nos quedamos con 48892 registros de llamadas para el análisis.
Ahora procederemos al enriquecimiento de los datos.
Llamadas
# Cambiando tipo de datos en la columna de fecha
calls['date'] = (
# Convirtiendo de object a datetime
pd.to_datetime(calls['date'],
format="%Y-%m-%d %H:%M:%S%z",
utc=False)
# Extrayendo solo fecha y hora sin el Huso horario
.dt.strftime("%Y-%m-%d %H:%M:%S")
# Extrayendo el día de la llamada
).astype("datetime64[D]")
calls['date']
0 2019-08-04
1 2019-08-05
2 2019-08-05
3 2019-08-05
4 2019-08-05
...
48887 2019-11-10
48888 2019-11-10
48889 2019-11-11
48890 2019-11-15
48891 2019-11-19
Name: date, Length: 48892, dtype: datetime64[ns]
# Incluyendo semana de la llamada en la tabla
calls['call_week'] = calls['date'].dt.isocalendar().week
calls[['date','call_week']]
| date | call_week | |
|---|---|---|
| 0 | 2019-08-04 | 31 |
| 1 | 2019-08-05 | 32 |
| 2 | 2019-08-05 | 32 |
| 3 | 2019-08-05 | 32 |
| 4 | 2019-08-05 | 32 |
| ... | ... | ... |
| 48887 | 2019-11-10 | 45 |
| 48888 | 2019-11-10 | 45 |
| 48889 | 2019-11-11 | 46 |
| 48890 | 2019-11-15 | 46 |
| 48891 | 2019-11-19 | 47 |
48892 rows × 2 columns
Conclusión intermedia
Convertimos los datos al tipo de dato datetime64, e incluimos la semana en la que fue realizada la llamada.
# Calculando el tiempo que tardó la llamada en ser tomada
calls['ring_time'] = calls['total_call_duration'] - calls['call_duration']
calls[['is_missed_call','total_call_duration','call_duration','ring_time']].head(10)
| is_missed_call | total_call_duration | call_duration | ring_time | |
|---|---|---|---|---|
| 0 | True | 4 | 0 | 4 |
| 1 | True | 5 | 0 | 5 |
| 2 | True | 1 | 0 | 1 |
| 3 | False | 18 | 10 | 8 |
| 4 | True | 25 | 0 | 25 |
| 5 | False | 29 | 3 | 26 |
| 6 | True | 50 | 0 | 50 |
| 7 | True | 35 | 0 | 35 |
| 8 | True | 62 | 0 | 62 |
| 9 | True | 29 | 0 | 29 |
Conclusión intermedia
Tenemos la cantidad de segundos que pasó el teléfono en espera antes de ser tomado o en su defecto, cerrado la llamada.
Clientes
# Convirtiendo la fecha de la tabla de clientes a datetime
clients['date_start'] = clients['date_start'].astype("datetime64[D]")
clients['date_start']
0 2019-08-15
1 2019-08-23
2 2019-10-29
3 2019-09-01
4 2019-10-16
...
727 2019-08-08
728 2019-08-23
729 2019-08-28
730 2019-08-22
731 2019-08-08
Name: date_start, Length: 732, dtype: datetime64[ns]
Conclusión
Convertimos la columna de fechas de la tabla de clientes a datetime.
Uniendo tablas
Incluiremos la información de los clientes en la tabla de llamadas para identificar fecha de inicio del ciclo de vida y el plan que tiene un cliente determinado.
# Uniendo tablas
calls = calls.merge(clients, on='user_id', how='left')
calls
| user_id | date | direction | internal | operator_id | is_missed_call | calls_count | call_duration | total_call_duration | call_week | ring_time | tariff_plan | date_start | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 166377 | 2019-08-04 | in | False | unknown | True | 2 | 0 | 4 | 31 | 4 | B | 2019-08-01 |
| 1 | 166377 | 2019-08-05 | out | True | 880022.0 | True | 3 | 0 | 5 | 32 | 5 | B | 2019-08-01 |
| 2 | 166377 | 2019-08-05 | out | True | 880020.0 | True | 1 | 0 | 1 | 32 | 1 | B | 2019-08-01 |
| 3 | 166377 | 2019-08-05 | out | True | 880020.0 | False | 1 | 10 | 18 | 32 | 8 | B | 2019-08-01 |
| 4 | 166377 | 2019-08-05 | out | False | 880022.0 | True | 3 | 0 | 25 | 32 | 25 | B | 2019-08-01 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 48887 | 168606 | 2019-11-10 | out | True | 957922.0 | False | 1 | 0 | 25 | 45 | 25 | C | 2019-10-31 |
| 48888 | 168606 | 2019-11-10 | out | True | 957922.0 | True | 1 | 0 | 38 | 45 | 38 | C | 2019-10-31 |
| 48889 | 168606 | 2019-11-11 | out | True | 957922.0 | False | 2 | 479 | 501 | 46 | 22 | C | 2019-10-31 |
| 48890 | 168606 | 2019-11-15 | out | True | 957922.0 | False | 4 | 3130 | 3190 | 46 | 60 | C | 2019-10-31 |
| 48891 | 168606 | 2019-11-19 | in | False | unknown | True | 2 | 0 | 64 | 47 | 64 | C | 2019-10-31 |
48892 rows × 13 columns
Conclusión
Ya con todos los datos incluidos, procederemos a iniciar el análisis exploratorio.
# Creando lista de columnas para iterar
distribution_list = ['call_week', 'direction', 'internal', 'is_missed_call', 'calls_count', 'call_duration',
'total_call_duration', 'ring_time', 'tariff_plan']
time_distribution_list = ['calls_count', 'call_duration', 'total_call_duration', 'ring_time']
# Trazando histogramas
for i in distribution_list:
if i in time_distribution_list:
hist = px.histogram(calls, x=i, title=f'Distribution of {i}', log_y=True)
hist.show()
else:
hist = px.histogram(calls, x=i, title=f'Distribution of {i}')
hist.show()
Conclusión intermedia
Una vez observadas las distribuciones, nos percatamos que tenemos valores atípicos en las variables calls_count, call_duration, total_call_duration y ring_time. Todas estas estrechamente vinculadas.
Adicionalmente, vemos que para las semanas 31 a 34 existen pocos registros para analizar. Filtraremos la tabla excluyendo estos valores atípicos, filtraremos para los valores que estén por debajo del cuantil .95 para luego trazar nuevamente los histogramas y ver como cambiaron los datos.
# Calculando número total de filas antes de filtrar la tabla
calls.shape[0]
48892
filtered_calls = (
calls[(calls['call_week'] >34)&
(calls['calls_count'] <= calls['calls_count'].quantile(.95)) &
(calls['call_duration'] <= calls['call_duration'].quantile(.95)) &
(calls['total_call_duration'] <= calls['total_call_duration'].quantile(.95)) &
(calls['ring_time'] <= calls['ring_time'].quantile(.95))
]
).reset_index(drop=True)
filtered_calls
| user_id | date | direction | internal | operator_id | is_missed_call | calls_count | call_duration | total_call_duration | call_week | ring_time | tariff_plan | date_start | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 166377 | 2019-08-26 | out | True | 880022.0 | True | 3 | 0 | 0 | 35 | 0 | B | 2019-08-01 |
| 1 | 166377 | 2019-08-26 | in | False | 880028.0 | False | 2 | 285 | 302 | 35 | 17 | B | 2019-08-01 |
| 2 | 166377 | 2019-08-26 | out | False | 880026.0 | False | 28 | 3298 | 3395 | 35 | 97 | B | 2019-08-01 |
| 3 | 166377 | 2019-08-26 | out | False | 880028.0 | True | 4 | 0 | 241 | 35 | 241 | B | 2019-08-01 |
| 4 | 166377 | 2019-08-26 | out | False | 880022.0 | False | 3 | 1079 | 1093 | 35 | 14 | B | 2019-08-01 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 43786 | 168606 | 2019-11-10 | out | True | 957922.0 | False | 1 | 0 | 25 | 45 | 25 | C | 2019-10-31 |
| 43787 | 168606 | 2019-11-10 | out | True | 957922.0 | True | 1 | 0 | 38 | 45 | 38 | C | 2019-10-31 |
| 43788 | 168606 | 2019-11-11 | out | True | 957922.0 | False | 2 | 479 | 501 | 46 | 22 | C | 2019-10-31 |
| 43789 | 168606 | 2019-11-15 | out | True | 957922.0 | False | 4 | 3130 | 3190 | 46 | 60 | C | 2019-10-31 |
| 43790 | 168606 | 2019-11-19 | in | False | unknown | True | 2 | 0 | 64 | 47 | 64 | C | 2019-10-31 |
43791 rows × 13 columns
# Calculando proporción de datos de datos eliminados
(calls.shape[0] - filtered_calls.shape[0]) / calls.shape[0]
0.10433199705473288
Conclusión intermedia
Eliminamos el 10.43% de los datos para deshacernos de los valores atípicos, trazaremos nuevamente las distribuciones para ver como han cambiado nuestros datos.
# Trazando histogramas
for i in distribution_list:
if i in time_distribution_list:
hist = px.histogram(filtered_calls, x=i, title=f'Distribution of {i}', log_y=True)
hist.show()
else:
hist = px.histogram(filtered_calls, x=i, title=f'Distribution of {i}')
hist.show()
Conclusión
Luego de filtrar los datos observamos que se mantienen una cantidad importante de valores atípicos en las variables calls_duration, total_calls_duration y ring_time. Sin embargo, procederemos con el análisis, considerndo que debido al alto volumen de llamadas perdidas, los datos están sesgados a la derecha.
# Calculando agregación por operador y cantidad de llamadas atendidas y no atendidas
calls_by_operators = (filtered_calls
.groupby(['call_week','operator_id', 'is_missed_call'], as_index=False)
.agg({'calls_count':'sum'})
)
# Transponiendo los resultados
calls_by_operators = (calls_by_operators
.pivot(index=['call_week', 'operator_id'], columns='is_missed_call', values='calls_count')
.reset_index()
.fillna(0)
)
# Cambiando nombre de las columnas
calls_by_operators.columns = ['call_week','operator_id', 'calls_count', 'missed_calls_count']
# Cambiando tipo de dato a entero de las columnas de cantida de llamadas
calls_by_operators[['calls_count', 'missed_calls_count']] = (calls_by_operators[['calls_count', 'missed_calls_count']]
.astype('int')
)
# Calculando proporción de llamadas concretadas (salientes y entrantes)
calls_by_operators['missed_calls_proportion'] = abs(calls_by_operators['calls_count'] / (calls_by_operators['calls_count'] +
calls_by_operators['missed_calls_count'])
-1)
calls_by_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | |
|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 |
| ... | ... | ... | ... | ... | ... |
| 5547 | 48 | 972412.0 | 36 | 25 | 0.409836 |
| 5548 | 48 | 972460.0 | 23 | 28 | 0.549020 |
| 5549 | 48 | 973120.0 | 1 | 2 | 0.666667 |
| 5550 | 48 | 973286.0 | 2 | 0 | 0.000000 |
| 5551 | 48 | unknown | 6 | 2996 | 0.998001 |
5552 rows × 5 columns
Conclusión intermedia
Calculamos el conteo de llamadas por operador (atendidas y perdidas), procederemos a calcular los tiempos promedios de las llamadas que sí fueron atendidas.
# Agrupando por operador para calcular agregaciones, considerando que el operador tomó la llamada.
calls_duration = (filtered_calls
.loc[filtered_calls['is_missed_call'] == False]
.groupby(['call_week','operator_id'], as_index=False)
.agg({'call_duration':['mean', 'median']})
.reset_index(drop=True)
)
# Renombrando las columnas
calls_duration.columns = ['call_week','operator_id','avg_call_duration','median_call_duration']
calls_duration
| call_week | operator_id | avg_call_duration | median_call_duration | |
|---|---|---|---|---|
| 0 | 35 | 879896.0 | 324.000000 | 251.0 |
| 1 | 35 | 879898.0 | 168.500000 | 168.5 |
| 2 | 35 | 880022.0 | 659.000000 | 659.0 |
| 3 | 35 | 880026.0 | 2211.333333 | 2512.5 |
| 4 | 35 | 880028.0 | 1191.000000 | 989.0 |
| ... | ... | ... | ... | ... |
| 5220 | 48 | 972412.0 | 1166.000000 | 1450.0 |
| 5221 | 48 | 972460.0 | 304.500000 | 61.5 |
| 5222 | 48 | 973120.0 | 5.000000 | 5.0 |
| 5223 | 48 | 973286.0 | 17.000000 | 17.0 |
| 5224 | 48 | unknown | 172.000000 | 159.0 |
5225 rows × 4 columns
Conclusión intermedia
Realizadas las agregaciones de tiempo de duración de las llamadas, las incluiremos en la tabla principal de agregaciones por operador.
# Uniendo tablas de agregaciones a la tabla principal
calls_by_operators = calls_by_operators.merge(calls_duration, on=['call_week','operator_id'], how='left')
calls_by_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | |
|---|---|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 | 324.000000 | 251.0 |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 | 168.500000 | 168.5 |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 | 659.000000 | 659.0 |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 | 2211.333333 | 2512.5 |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 | 1191.000000 | 989.0 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 5547 | 48 | 972412.0 | 36 | 25 | 0.409836 | 1166.000000 | 1450.0 |
| 5548 | 48 | 972460.0 | 23 | 28 | 0.549020 | 304.500000 | 61.5 |
| 5549 | 48 | 973120.0 | 1 | 2 | 0.666667 | 5.000000 | 5.0 |
| 5550 | 48 | 973286.0 | 2 | 0 | 0.000000 | 17.000000 | 17.0 |
| 5551 | 48 | unknown | 6 | 2996 | 0.998001 | 172.000000 | 159.0 |
5552 rows × 7 columns
Conclusión intermedia
Luego de la unión de estas primeras tablas, calcularemos la cantidad de llamadas internas y externas por operador.
# Calculando agregación por operador y cantidad de llamadas internas
internal_calls = (filtered_calls
.loc[filtered_calls['is_missed_call'] == False]
.groupby(['call_week','operator_id', 'internal'], as_index=False)
.agg({'calls_count':'sum'})
)
# Transponiendo los resultados
internal_calls = (internal_calls
.pivot(index=['call_week','operator_id'], columns='internal', values='calls_count')
.reset_index()
.fillna(0)
)
# Cambiando nombre de las columnas
internal_calls.columns = ['call_week','operator_id', 'external_calls', 'internal_calls']
# Cambiando tipo de dato a entero de las columnas de cantida de llamadas
internal_calls[['external_calls', 'internal_calls']] = (internal_calls[['external_calls', 'internal_calls']]
.astype('int')
)
# Calculando proporción de llamadas externas
internal_calls['external_proportion'] = internal_calls['external_calls'] / (internal_calls['external_calls'] +
internal_calls['internal_calls'])
internal_calls
| call_week | operator_id | external_calls | internal_calls | external_proportion | |
|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 0 | 1.000000 |
| 1 | 35 | 879898.0 | 8 | 0 | 1.000000 |
| 2 | 35 | 880022.0 | 4 | 0 | 1.000000 |
| 3 | 35 | 880026.0 | 113 | 0 | 1.000000 |
| 4 | 35 | 880028.0 | 86 | 0 | 1.000000 |
| ... | ... | ... | ... | ... | ... |
| 5220 | 48 | 972412.0 | 36 | 0 | 1.000000 |
| 5221 | 48 | 972460.0 | 22 | 1 | 0.956522 |
| 5222 | 48 | 973120.0 | 1 | 0 | 1.000000 |
| 5223 | 48 | 973286.0 | 2 | 0 | 1.000000 |
| 5224 | 48 | unknown | 2 | 4 | 0.333333 |
5225 rows × 5 columns
# Uniendo tabla de llamadas internas y externas a tabla con el resto de las agregaciones por operador
calls_by_operators = calls_by_operators.merge(internal_calls, on=['call_week','operator_id'], how='left')
calls_by_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | external_calls | internal_calls | external_proportion | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 | 324.000000 | 251.0 | 32.0 | 0.0 | 1.000000 |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 | 168.500000 | 168.5 | 8.0 | 0.0 | 1.000000 |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 | 659.000000 | 659.0 | 4.0 | 0.0 | 1.000000 |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 | 2211.333333 | 2512.5 | 113.0 | 0.0 | 1.000000 |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 | 1191.000000 | 989.0 | 86.0 | 0.0 | 1.000000 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 5547 | 48 | 972412.0 | 36 | 25 | 0.409836 | 1166.000000 | 1450.0 | 36.0 | 0.0 | 1.000000 |
| 5548 | 48 | 972460.0 | 23 | 28 | 0.549020 | 304.500000 | 61.5 | 22.0 | 1.0 | 0.956522 |
| 5549 | 48 | 973120.0 | 1 | 2 | 0.666667 | 5.000000 | 5.0 | 1.0 | 0.0 | 1.000000 |
| 5550 | 48 | 973286.0 | 2 | 0 | 0.000000 | 17.000000 | 17.0 | 2.0 | 0.0 | 1.000000 |
| 5551 | 48 | unknown | 6 | 2996 | 0.998001 | 172.000000 | 159.0 | 2.0 | 4.0 | 0.333333 |
5552 rows × 10 columns
Conclusión intermedia
Agregamos la cantidad de llamadas atendidas internas y externas. A continuación, calcularemos el tiempo que duró en atender la llamada un operador determinado.
# Calculando agregación por operadores, donde encontremos el promedio de "ring_time" para llamadas entrantes
avg_ring_time = (filtered_calls
.loc[(filtered_calls['is_missed_call'] == False) &
(filtered_calls['direction'] == 'in')]
.groupby(['call_week','operator_id'], as_index=False)
.agg({'ring_time':'mean'})
)
avg_ring_time.columns = ['call_week','operator_id', 'avg_ring_time']
avg_ring_time
| call_week | operator_id | avg_ring_time | |
|---|---|---|---|
| 0 | 35 | 879896.0 | 70.25 |
| 1 | 35 | 879898.0 | 25.00 |
| 2 | 35 | 880026.0 | 4.00 |
| 3 | 35 | 880028.0 | 18.50 |
| 4 | 35 | 882680.0 | 41.80 |
| ... | ... | ... | ... |
| 3674 | 48 | 971354.0 | 42.00 |
| 3675 | 48 | 972412.0 | 25.00 |
| 3676 | 48 | 972460.0 | 4.00 |
| 3677 | 48 | 973286.0 | 88.00 |
| 3678 | 48 | unknown | 16.00 |
3679 rows × 3 columns
# Uniendo con tabla de datos por operador
calls_by_operators = calls_by_operators.merge(avg_ring_time, on=['call_week','operator_id'], how='left')
calls_by_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | external_calls | internal_calls | external_proportion | avg_ring_time | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 | 324.000000 | 251.0 | 32.0 | 0.0 | 1.000000 | 70.25 |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 | 168.500000 | 168.5 | 8.0 | 0.0 | 1.000000 | 25.00 |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 | 659.000000 | 659.0 | 4.0 | 0.0 | 1.000000 | NaN |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 | 2211.333333 | 2512.5 | 113.0 | 0.0 | 1.000000 | 4.00 |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 | 1191.000000 | 989.0 | 86.0 | 0.0 | 1.000000 | 18.50 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 5547 | 48 | 972412.0 | 36 | 25 | 0.409836 | 1166.000000 | 1450.0 | 36.0 | 0.0 | 1.000000 | 25.00 |
| 5548 | 48 | 972460.0 | 23 | 28 | 0.549020 | 304.500000 | 61.5 | 22.0 | 1.0 | 0.956522 | 4.00 |
| 5549 | 48 | 973120.0 | 1 | 2 | 0.666667 | 5.000000 | 5.0 | 1.0 | 0.0 | 1.000000 | NaN |
| 5550 | 48 | 973286.0 | 2 | 0 | 0.000000 | 17.000000 | 17.0 | 2.0 | 0.0 | 1.000000 | 88.00 |
| 5551 | 48 | unknown | 6 | 2996 | 0.998001 | 172.000000 | 159.0 | 2.0 | 4.0 | 0.333333 | 16.00 |
5552 rows × 11 columns
Conclusión intermedia
Completamos las agregaciones por operador, por último, incluiremos una columna donde nos indique si el operador trabaja con llamadas salientes o recibe llamadas.
Para agregar esta columna, utilizaremos la tabla avg_ring_time donde los operadores que se reflejan en la misma, son los que trabajan recibiendo llamadas, el resto trabaja con llamadas salientes.
# Agregando columna con tipo de operación que realiza el operador.
calls_by_operators.loc[calls_by_operators['operator_id']
.isin(avg_ring_time['operator_id']), 'reciever_operator'] = True
# Completando los NaNs de la columna recipient_operator con False
calls_by_operators['reciever_operator'].fillna(False, inplace=True)
calls_by_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | external_calls | internal_calls | external_proportion | avg_ring_time | reciever_operator | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 | 324.000000 | 251.0 | 32.0 | 0.0 | 1.000000 | 70.25 | True |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 | 168.500000 | 168.5 | 8.0 | 0.0 | 1.000000 | 25.00 | True |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 | 659.000000 | 659.0 | 4.0 | 0.0 | 1.000000 | NaN | True |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 | 2211.333333 | 2512.5 | 113.0 | 0.0 | 1.000000 | 4.00 | True |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 | 1191.000000 | 989.0 | 86.0 | 0.0 | 1.000000 | 18.50 | True |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 5547 | 48 | 972412.0 | 36 | 25 | 0.409836 | 1166.000000 | 1450.0 | 36.0 | 0.0 | 1.000000 | 25.00 | True |
| 5548 | 48 | 972460.0 | 23 | 28 | 0.549020 | 304.500000 | 61.5 | 22.0 | 1.0 | 0.956522 | 4.00 | True |
| 5549 | 48 | 973120.0 | 1 | 2 | 0.666667 | 5.000000 | 5.0 | 1.0 | 0.0 | 1.000000 | NaN | False |
| 5550 | 48 | 973286.0 | 2 | 0 | 0.000000 | 17.000000 | 17.0 | 2.0 | 0.0 | 1.000000 | 88.00 | True |
| 5551 | 48 | unknown | 6 | 2996 | 0.998001 | 172.000000 | 159.0 | 2.0 | 4.0 | 0.333333 | 16.00 | True |
5552 rows × 12 columns
Conclusión
Finalizada la tabla de agregaciones por operador, observaremos las distribuciones de los campos contenidos en esta tabla para determinar umbrales donde identificaremos a los operadores no eficientes.
Definiendo umbrales ¿Cómo identifico si un operador no está siendo eficiente?¶
Observaremos las distribuciones y nos basaremos en los límites teóricos superiores o inferiores para determinar si un operador está siendo eficiente en sus labores, dependiendo del tipo de métrica que estemos evaluando.
Llamadas perdidas
Observaremos como se distribuyen las proporciones de llamadas perdidas por operador, considerando que sus funciones incluyan recibir llamadas.
# Creando tabla de agregaciones sin los operadores "desconocidos"
reciever_operators = (calls_by_operators
.loc[(calls_by_operators['operator_id'] != "unknown") &
(calls_by_operators['reciever_operator'] == True)]
.reset_index(drop=True)
)
reciever_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | external_calls | internal_calls | external_proportion | avg_ring_time | reciever_operator | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 35 | 879896.0 | 32 | 94 | 0.746032 | 324.000000 | 251.0 | 32.0 | 0.0 | 1.000000 | 70.25 | True |
| 1 | 35 | 879898.0 | 8 | 33 | 0.804878 | 168.500000 | 168.5 | 8.0 | 0.0 | 1.000000 | 25.00 | True |
| 2 | 35 | 880022.0 | 4 | 3 | 0.428571 | 659.000000 | 659.0 | 4.0 | 0.0 | 1.000000 | NaN | True |
| 3 | 35 | 880026.0 | 113 | 52 | 0.315152 | 2211.333333 | 2512.5 | 113.0 | 0.0 | 1.000000 | 4.00 | True |
| 4 | 35 | 880028.0 | 86 | 84 | 0.494118 | 1191.000000 | 989.0 | 86.0 | 0.0 | 1.000000 | 18.50 | True |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 4330 | 48 | 971102.0 | 50 | 0 | 0.000000 | 2004.333333 | 2980.0 | 50.0 | 0.0 | 1.000000 | 286.00 | True |
| 4331 | 48 | 971354.0 | 6 | 0 | 0.000000 | 371.500000 | 371.5 | 6.0 | 0.0 | 1.000000 | 42.00 | True |
| 4332 | 48 | 972412.0 | 36 | 25 | 0.409836 | 1166.000000 | 1450.0 | 36.0 | 0.0 | 1.000000 | 25.00 | True |
| 4333 | 48 | 972460.0 | 23 | 28 | 0.549020 | 304.500000 | 61.5 | 22.0 | 1.0 | 0.956522 | 4.00 | True |
| 4334 | 48 | 973286.0 | 2 | 0 | 0.000000 | 17.000000 | 17.0 | 2.0 | 0.0 | 1.000000 | 88.00 | True |
4335 rows × 12 columns
# Trazando histograma para variable missed_calls_count
px.box(reciever_operators,x='call_week', y='missed_calls_proportion', title='Missed calls proportion by operators throughout the weeks')
Conclusión intermedia
En todas las semanas tenemos las distribuciones sesgadas a la derecha. A partir de la cuarta semana, encontramos que las distribuciones se concentran aún más entre las proporciones bajas de llamadas perdidas, y son pocos los operadores que se encuentran en ese sesgo, de hecho, podemos observar varios valores atípicos que nos demuestran que los operadores cada vez tienen un menor porcentaje de llamadas perdidas.
Calcularemos un promedio entre la mediana y la media de esta distribución para determinar un umbral que identifique si el operador en cuestión está en desempeño deficiente.
# Calculando umbral para determinar si un operador no fue eficiente recibiendo llamadas.
missed_calls_threshold = ((reciever_operators['missed_calls_proportion'].median() +
reciever_operators['missed_calls_proportion'].mean()) / 2).round(2)
missed_calls_threshold
0.19
Conclusión
De acuerdo a los cálculos, un operador no debe promediar más de un 19% de llamadas perdidas por semana para no considerarse como "no eficiente".
Continuaremos calculando el resto de los umbrales antes de determinar la efectividad de los operadores.
Tiempo de espera
Observaremos como se distribuyen las proporciones de los tiempos de espera por operador, considerando que sus funciones incluyan recibir llamadas.
# Trazando histograma para variable ring_time
px.box(reciever_operators,x='call_week' ,y='avg_ring_time', title='Average ring time on incoming calls throughout the weeks')
Conclusión intermedia
Observando las distribuciones por semana, se evidencia una gran presencia de valores atípicos para todas las semanas, por lo tanto, para este umbral, utilizaremos el promedio general de la variable avg_ring_time.
# Calculando umbral para determinar si un operador no fue eficiente recibiendo llamadas.
ring_time_threshold = (reciever_operators['avg_ring_time'].mean()).round()
ring_time_threshold
64.0
Conclusión
De acuerdo a los cálculos, un operador no debe promediar más de 64 segundos para atender una llamada en una semana de labores para no ser considerado como "no eficiente".
Cantidad de llamadas realizadas
Observaremos como se distribuyen las proporciones de las cantidades de llamadas realizadas para los operadores que se desempeñan llamando clientes.
# Creando tabla de agregaciones sin los operadores "desconocidos" y para operadores que trabajan llamando a clientes
outgoing_operators = (calls_by_operators
.loc[(calls_by_operators['operator_id'] != "unknown") &
(calls_by_operators['reciever_operator'] == False)]
.reset_index(drop=True)
)
outgoing_operators
| call_week | operator_id | calls_count | missed_calls_count | missed_calls_proportion | avg_call_duration | median_call_duration | external_calls | internal_calls | external_proportion | avg_ring_time | reciever_operator | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 35 | 880240.0 | 26 | 11 | 0.297297 | 1706.00 | 1706.0 | 26.0 | 0.0 | 1.0 | NaN | False |
| 1 | 35 | 886146.0 | 10 | 0 | 0.000000 | 731.00 | 731.0 | 10.0 | 0.0 | 1.0 | NaN | False |
| 2 | 35 | 887992.0 | 5 | 2 | 0.285714 | 142.00 | 142.0 | 5.0 | 0.0 | 1.0 | NaN | False |
| 3 | 35 | 890416.0 | 36 | 27 | 0.428571 | 2111.50 | 1998.5 | 36.0 | 0.0 | 1.0 | NaN | False |
| 4 | 35 | 890420.0 | 44 | 58 | 0.568627 | 2003.75 | 1850.5 | 44.0 | 0.0 | 1.0 | NaN | False |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 1198 | 48 | 970484.0 | 2 | 5 | 0.714286 | 75.00 | 75.0 | 2.0 | 0.0 | 1.0 | NaN | False |
| 1199 | 48 | 970486.0 | 4 | 2 | 0.333333 | 150.00 | 150.0 | 4.0 | 0.0 | 1.0 | NaN | False |
| 1200 | 48 | 972408.0 | 4 | 2 | 0.333333 | 200.00 | 200.0 | 4.0 | 0.0 | 1.0 | NaN | False |
| 1201 | 48 | 972410.0 | 40 | 37 | 0.480519 | 1888.50 | 1888.5 | 40.0 | 0.0 | 1.0 | NaN | False |
| 1202 | 48 | 973120.0 | 1 | 2 | 0.666667 | 5.00 | 5.0 | 1.0 | 0.0 | 1.0 | NaN | False |
1203 rows × 12 columns
# Trazando histograma para variable ring_time
px.box(outgoing_operators,x='call_week' ,y='calls_count',
title='Outgoing calls count by operators throughout weeks')
Conclusión intermedia
Las distribuciones de llamadas realizadas (exitosas) por semana nos demuestran que a partir de la tercera semana observada se reflejan valores atípicamente altos, sin embargo, para esta métrica estaremos considerando como no eficientes los operadores que no sobrepasen un de terminado umbral.
Definiremos dicho umbral basándonos en el promedio de esta distribución.
# Calculando umbral para determinar si un operador no fue eficiente recibiendo llamadas.
calls_count_threshold = (outgoing_operators['calls_count'].mean()).round()
calls_count_threshold
24.0
Conclusión
De acuerdo a los cálculos, podemos concluir que los operadores no deben tener menos de 24 llamadas exitosas por semana para no ser considerados como no eficientes.
Proporción de llamadas perdidas
# Incluyendo columna de eficiencia para métrica de llamadas perdidas
reciever_operators['missed_calls_efficiency'] = reciever_operators['missed_calls_proportion'] < missed_calls_threshold
reciever_operators[['calls_count','missed_calls_proportion', 'missed_calls_efficiency']]
| calls_count | missed_calls_proportion | missed_calls_efficiency | |
|---|---|---|---|
| 0 | 32 | 0.746032 | False |
| 1 | 8 | 0.804878 | False |
| 2 | 4 | 0.428571 | False |
| 3 | 113 | 0.315152 | False |
| 4 | 86 | 0.494118 | False |
| ... | ... | ... | ... |
| 4330 | 50 | 0.000000 | True |
| 4331 | 6 | 0.000000 | True |
| 4332 | 36 | 0.409836 | False |
| 4333 | 23 | 0.549020 | False |
| 4334 | 2 | 0.000000 | True |
4335 rows × 3 columns
# Creando agrupación para gráfico
missed_calls_group = (reciever_operators
.groupby(['call_week','missed_calls_efficiency'], as_index=False)
.agg({'calls_count':'sum',
'avg_call_duration':'mean',
'missed_calls_proportion':'mean'})
)
missed_calls_group.head()
| call_week | missed_calls_efficiency | calls_count | avg_call_duration | missed_calls_proportion | |
|---|---|---|---|---|---|
| 0 | 35 | False | 1875 | 662.124802 | 0.504622 |
| 1 | 35 | True | 569 | 236.002857 | 0.016718 |
| 2 | 36 | False | 2347 | 586.935743 | 0.442635 |
| 3 | 36 | True | 1352 | 321.036174 | 0.031889 |
| 4 | 37 | False | 3310 | 688.899624 | 0.468593 |
# Trazando gráfico de línea donde se refleje el comportamiento por semana
missed_calls_list = ['missed_calls_proportion','calls_count', 'avg_call_duration']
for i in missed_calls_list:
call_lineplot = px.line(missed_calls_group, x='call_week', y=i,
color='missed_calls_efficiency',
title=f'Distribution of {i} throughout observed weeks'
)
call_lineplot.show()
Conclusión
Los gráficos demuestran que existe una diferencia amplia en la cantidad de llamadas recibidas entre el grupo "eficiente" en el indicador de proporción de llamadas perdidas. Este mismo comportamiento se repite en la duración promedio de las llamadas. Una pequeña excepción parece ser en la semana 41 donde la cantidad de llamadas parece similar para ambos grupos.
A través de estos resultados pudieramos inferir que una de las razones o la razón por la que un grupo tiene un mejor tiempo de respuesta ante las llamadas es porque reciben una menor cantidad y porque tardan menos con los clientes en las mismas.
Tiempo promedio para contestar una llamada
# Incluyendo columna de eficiencia para métrica de tiempo promedio para contestar una llamada
reciever_operators['avg_ring_time_efficiency'] = (reciever_operators['avg_ring_time'] <
ring_time_threshold)
reciever_operators[['avg_ring_time','avg_ring_time_efficiency']]
| avg_ring_time | avg_ring_time_efficiency | |
|---|---|---|
| 0 | 70.25 | False |
| 1 | 25.00 | True |
| 2 | NaN | False |
| 3 | 4.00 | True |
| 4 | 18.50 | True |
| ... | ... | ... |
| 4330 | 286.00 | False |
| 4331 | 42.00 | True |
| 4332 | 25.00 | True |
| 4333 | 4.00 | True |
| 4334 | 88.00 | False |
4335 rows × 2 columns
# Creando agrupación para gráfico
ring_time_group = (reciever_operators
.groupby(['call_week','avg_ring_time_efficiency'], as_index=False)
.agg({'calls_count':'sum',
'avg_call_duration':'mean',
'avg_ring_time':'mean'})
)
ring_time_group.head()
| call_week | avg_ring_time_efficiency | calls_count | avg_call_duration | avg_ring_time | |
|---|---|---|---|---|---|
| 0 | 35 | False | 945 | 655.673016 | 111.144737 |
| 1 | 35 | True | 1499 | 370.095049 | 22.689670 |
| 2 | 36 | False | 1712 | 740.339237 | 102.261447 |
| 3 | 36 | True | 1987 | 336.208007 | 25.591117 |
| 4 | 37 | False | 1930 | 798.697131 | 119.098095 |
# Trazando gráfico de línea donde se refleje el comportamiento por semana
ring_time_list = ['avg_ring_time','calls_count', 'avg_call_duration']
for i in ring_time_list:
call_lineplot = px.line(ring_time_group, x='call_week', y=i,
color='avg_ring_time_efficiency',
title=f'Distribution of {i} throughout observed weeks'
)
call_lineplot.show()
Conclusión
Inicialmente, el grupo que no fue eficiente al momento de tomar la llamada tenía menos llamadas que el grupo con eficiencia en este mismo campo, de hecho, la diferencia entre ambos grupos para las primeras cuatro semanas fue de unos 80 segundos en promedio. Luego cuando ambos grupos comienzan a recibir más llamadas, la distancia entre la duración promedio se separa por mucho más (hasta unos 160 segundos).
Importante destacar, que el comportamiento del grupo que fue eficiente, se mantuvo estable en el transcurso de las semanas, tomandose no más de 30 segundos en tomar la llamada.
Por último, podemos observar que las duraciones de las llamadas para el grupo que no fue eficiente es considerablemente mayor que su contraparte, por lo tanto, podríamos inferir que la duración de la llamada es una de las principales causantes de que los operadores tarden en tomar una llamada a tiempo.
Cantidad de llamadas salientes
# Incluyendo columna de eficiencia para métrica de cantidad de llamadas exitosas realizadas
outgoing_operators['calls_count_efficiency'] = (outgoing_operators['calls_count'] >=
calls_count_threshold)
outgoing_operators[['calls_count','calls_count_efficiency']]
| calls_count | calls_count_efficiency | |
|---|---|---|
| 0 | 26 | True |
| 1 | 10 | False |
| 2 | 5 | False |
| 3 | 36 | True |
| 4 | 44 | True |
| ... | ... | ... |
| 1198 | 2 | False |
| 1199 | 4 | False |
| 1200 | 4 | False |
| 1201 | 40 | True |
| 1202 | 1 | False |
1203 rows × 2 columns
# Creando agrupación para gráfico
outgoing_calls_group = (outgoing_operators
.groupby(['call_week','calls_count_efficiency'], as_index=False)
.agg({'calls_count':['sum','mean'],
'missed_calls_count':'sum',
'avg_call_duration':'mean'})
)
outgoing_calls_group.columns = ['call_week', 'calls_count_efficiency', 'calls_sum', 'calls_mean',
'missed_calls_count', 'avg_call_duration']
outgoing_calls_group.head()
| call_week | calls_count_efficiency | calls_sum | calls_mean | missed_calls_count | avg_call_duration | |
|---|---|---|---|---|---|---|
| 0 | 35 | False | 259 | 10.791667 | 286 | 1033.515942 |
| 1 | 35 | True | 175 | 35.000000 | 191 | 1727.200000 |
| 2 | 36 | False | 123 | 5.347826 | 460 | 844.744444 |
| 3 | 36 | True | 266 | 33.250000 | 390 | 2153.270833 |
| 4 | 37 | False | 257 | 6.763158 | 531 | 846.149524 |
# Trazando gráfico de línea donde se refleje el comportamiento por semana
outgoing_calls_list = ['calls_mean', 'calls_sum','missed_calls_count', 'avg_call_duration']
for i in outgoing_calls_list:
call_lineplot = px.line(outgoing_calls_group, x='call_week', y=i,
color='calls_count_efficiency',
title=f'Distribution of {i} throughout observed weeks'
)
call_lineplot.show()
Conclusión
Desde el comienzo de las observaciones semanales, los operadores de ambos grupos presentaron un número colectivo de llamadas (por grupo) similar, sin embargo, el grupo de operadores que cumplieron con la cuota fue separando su cantidad total de llamadas como grupo al pasar de las semanas. En promedio, cada operador perteneciente al grupo categorizado como "eficiente" realizó unas 35 llamadas para la primera semana de observaciones, luego tuvo un pico de 80 llamadas por semana para luego estabilizarse en 72 llamadas exitosas por semana.
El grupo que quedó debajo del umbral establecido promedió no más de 10 llamadas por semana durante el período de observación.
Es importante destacar que ambos grupos de operadores tuvieron un comportamiento similar de acuerdo al conteo de llamadas no exitosas, sin embargo, el aumento considerable para las últimas 3 semanas de observaciones demuestra un nivel de compromiso superior a su contraparte para llegar a la cuota de llamadas exitosas que fue reflejando durante su ciclo de vida.
Por otra parte, el grupo que fue eficiente en la cantidad de llamadas realizadas, promedió un tiempo superior en cada llamada que su contraparte. Esto contrasta con la teoría que nos indicaba que los operadores realizaban menos llamadas dependiendo de la duración que hayan tenido las mismas.
# Creando filtros de operadores eficientes e ineficientes
calls_efficient_filter = (reciever_operators
.loc[reciever_operators['missed_calls_efficiency'] == True,'operator_id']
.reset_index(drop=True)
)
calls_inefficient_filter = (reciever_operators
.loc[reciever_operators['missed_calls_efficiency'] == False, 'operator_id']
.reset_index(drop=True)
)
# Creando tablas de llamadas filtradas por grupo de operadores en los grupos de la métrica
calls_efficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(calls_efficient_filter)]
.reset_index(drop=True)
)
calls_inefficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(calls_inefficient_filter)]
.reset_index(drop=True)
)
Comprobación de varianzas
# Estableciendo factor de significancia "alpha" para prueba de hipótesis
alpha = 0.05
# Realizando prueba de varianzas "levene"
calls_levene_st, calls_levene_pvalue = st.levene(calls_efficient_operators['calls_count'],
calls_inefficient_operators['calls_count'])
print(calls_levene_pvalue)
if calls_levene_pvalue < alpha:
print('Hipótesis nula rechazada, las varianzas no son iguales')
else:
print('Hipótesis nula no rechazada, las varianzas son iguales')
8.867754524586642e-58 Hipótesis nula rechazada, las varianzas no son iguales
Conclusión Intermedia
La prueba Levene nos deja saber que las varianzas entre las distribuciones de la cantidad de llamadas entre los 2 grupos de operadores no es igual.
Comprobación de medias
# Realizando prueba de medias ttest
calls_ttest_st, calls_ttest_pvalue = st.ttest_ind(calls_efficient_operators['calls_count'],
calls_inefficient_operators['calls_count'], equal_var=False)
print(calls_ttest_pvalue)
if calls_ttest_pvalue < alpha:
print('Hipótesis nula rechazada, las medias no son iguales')
else:
print('Hipótesis nula no rechazada, las medias son iguales')
1.1010150131122e-69 Hipótesis nula rechazada, las medias no son iguales
Conclusión
Los resultados de la prueba de medias nos refleja que las distribuciones de las cantidades de llamadas por grupo de operador no son iguales. Basándonos en los gráficos mostrados en la sección anterior, se evidencia que los operadores con eficiencia en la métrica de proporción de llamadas perdidas tuvo una menor cantidad de llamadas.
Duración de llamadas entre operadores receptores¶
Comprobaremos la hipótesis sobre si los operadores receptores duraron la misma cantidad de tiempo en sus llamadas considerando su eficiencia en la métrica de "eficiencia en tiempo de respuesta", donde:
$H0=$ La duración de las llamadas fue igual para ambos grupos
$H1=$ La duración de las llamadas no fue igual para ambos grupos
# Creando filtros de operadores eficientes e ineficientes
ring_time_efficient_filter = (reciever_operators
.loc[reciever_operators['avg_ring_time_efficiency'] == True,'operator_id']
.reset_index(drop=True)
)
ring_time_inefficient_filter = (reciever_operators
.loc[reciever_operators['avg_ring_time_efficiency'] == False, 'operator_id']
.reset_index(drop=True)
)
# Creando tablas filtradas por grupo de operadores
ring_time_efficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(ring_time_efficient_filter)]
.reset_index(drop=True)
)
ring_time_inefficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(ring_time_inefficient_filter)]
.reset_index(drop=True)
)
Comprobación de varianzas
# Estableciendo factor de significancia "alpha" para prueba de hipótesis
alpha = 0.05
# Realizando prueba de varianzas "levene"
ring_time_levene_st, ring_time_levene_pvalue = st.levene(ring_time_efficient_operators['call_duration'],
ring_time_inefficient_operators['call_duration'])
print(ring_time_levene_pvalue)
if ring_time_levene_pvalue < alpha:
print('Hipótesis nula rechazada, las varianzas no son iguales')
else:
print('Hipótesis nula no rechazada, las varianzas son iguales')
1.651311638674036e-27 Hipótesis nula rechazada, las varianzas no son iguales
Conclusión Intermedia
La prueba Levene nos deja saber que las varianzas entre las distribuciones del tiempo de duración de las llamadas entre los 2 grupos de operadores no es igual.
Comprobación de medias
# Realizando prueba de medias ttest
ring_time_ttest_st, ring_time_ttest_pvalue = st.ttest_ind(ring_time_efficient_operators['call_duration'],
ring_time_inefficient_operators['call_duration'])
print(ring_time_ttest_pvalue)
if ring_time_ttest_pvalue < alpha:
print('Hipótesis nula rechazada, las medias no son iguales')
else:
print('Hipótesis nula no rechazada, las medias son iguales')
7.291685322432611e-25 Hipótesis nula rechazada, las medias no son iguales
Conclusión
Los resultados de la prueba de medias nos refleja que las distribuciones de los tiempos de duración de las llamadas por grupo de operador no son iguales. Basándonos en los gráficos mostrados en la sección anterior, se evidencia que los operadores con eficiencia en la métrica de eficiencia en tiempos de atención de una llamada tuvo un mayor tiempo de duración entre las llamadas atendidas.
Duración de llamadas entre operadores emisores¶
Comprobaremos la hipótesis sobre si los operadores que debían hacer llamadas tardaron el mismo tiempo en las llamadas entre los grupos "eficientes" y "no eficientes", donde:
$H0=$ La duración de las llamadas es igual para ambos grupos
$H1=$ La duración de las llamadas no es igual para ambos grupos
# Creando filtros de operadores eficientes e ineficientes
made_calls_efficient_filter = (outgoing_operators
.loc[outgoing_operators['calls_count_efficiency'] == True,'operator_id']
.reset_index(drop=True)
)
made_calls_inefficient_filter = (outgoing_operators
.loc[outgoing_operators['calls_count_efficiency'] == False,'operator_id']
.reset_index(drop=True)
)
# Creando tablas filtradas por grupo de operadores
made_calls_efficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(made_calls_efficient_filter)]
.reset_index(drop=True))
made_calls_inefficient_operators = (filtered_calls
.loc[filtered_calls['operator_id'].isin(made_calls_inefficient_filter)]
.reset_index(drop=True))
Comprobación de varianzas
# Estableciendo factor de significancia "alpha" para prueba de hipótesis
alpha = 0.05
# Realizando prueba de varianzas "levene"
made_calls_levene_st, made_calls_levene_pvalue = st.levene(made_calls_efficient_operators['call_duration'],
made_calls_inefficient_operators['call_duration'])
print(made_calls_levene_pvalue)
if made_calls_levene_pvalue < alpha:
print('Hipótesis nula rechazada, las varianzas no son iguales')
else:
print('Hipótesis nula no rechazada, las varianzas son iguales')
7.148803372721451e-12 Hipótesis nula rechazada, las varianzas no son iguales
Conclusión Intermedia
La prueba Levene nos deja saber que las varianzas entre las distribuciones de la cantidad de llamadas entre los 2 grupos de operadores no es igual.
Comprobación de medias
# Realizando prueba de medias ttest
made_calls_ttest_st, made_calls_ttest_pvalue = st.ttest_ind(made_calls_efficient_operators['call_duration'],
made_calls_inefficient_operators['call_duration'],
equal_var=False)
print(made_calls_ttest_pvalue)
if made_calls_ttest_pvalue < alpha:
print('Hipótesis nula rechazada, las medias no son iguales')
else:
print('Hipótesis nula no rechazada, las medias son iguales')
1.3695283159104214e-11 Hipótesis nula rechazada, las medias no son iguales
Conclusión
De acuerdo a los resultados de la prueba, rechazamos la hipótesis de que la media de duración de llamadas para los operadores eficientes en esta métrica sea igual que su contraparte. Basándonos en el gráfico en la sección anterior donde se compara los tiempos de llamada de ambos grupos, podemos observar que el grupo de operadores que cumplió con el indicador de eficiencia tuvo un mayor tiempo de duración de llamadas en promedio.
Conclusión General y Recomendaciones¶
Iniciamos importando tablas con la información de las llamadas recibidas e información de los clientes que hacían dichas llamadas. Luego de realizar la exploración inicial de los datos pudimos evidenciar la presencia de valores ausentes y duplicados. Una vez realizado la investigación pertinente con respecto a los datos ausentes, nos deshicimos de los mismos.
Incluimos las semanas de cada llamada en la tabla para realizar agregaciones del desempeño de cada operador. En estas agregaciones incluimos los promedios del tiempo de llamada, cantidad de llamadas realizadas, proporción de llamadas perdidas, tiempo de espera antes de tomar una llamada, entre otros.
Buscamos la presencia de valores atípicos en los datos, y filtramos los mismos para quedarnos con un 95% de los datos de la distribución y evitar un mayor sesgo en nuestros resultados.
Para definir los umbrales que nos indicarían si un operador es o no eficiente en una métrica en particular, observamos la distribución de "proporción de llamadas perdidas por operador", "duración de tiempo en espera antes de atender una llamada" y "cantidad de llamadas realizadas". Promediamos los valores observados en el transcurso de las 14 semanas que contenían los datos mencionados y establecimos los umbrales en particular.
Incluimos una columna en los datos de eficiencia dependiendo de la métrica y luego probamos tres diferentes hipótesis donde nos consultamos si la "cantidad de llamadas" entre operadores del grupo de eficiencia por "proporción de llamadas perdidas" era igual, de la misma forma, "duración de llamadas" entre los operadores del grupo de eficiencia de "duración de tiempo en espera antes de atender una llamada", y por último la "duración de las llamadas" para los grupos de operadores cuyas funciones incluían contactar clientes.
Recomendaciones
En el caso de las métricas que afectan a los operadores que reciben llamadas, podemos observar que el cumplimiento de estas está directamente afectado por la cantidad de llamadas que reciben y la duración de las mismas. Recomendariamos un entrenamiento en solución eficiente de problemas para reducir el tiempo de las llamadas que reciben, así como también evaluar la posibilidad de incluir más operadores que puedan atender este tipo de casos.
Para los operadores que deben hacer llamadas, pudimos evidenciar que la cantidad de llamadas exitosas que realizan no están influenciadas por el tiempo que tardan con los clientes, pudieramos recomendar evaluar de cerca a estos operadores ya que pudieran estar siendo afectados por un factor motivacional.
En el siguiente link se puede ver la presentación asociada a este proyecto.
¡Qué gran trabajo has hecho! 👍 Podemos aprobar el proyecto.
Felicidades por la calidad de tu análisis. Te animo a que sigas aprendiendo y desafiando tu potencial en los próximos sprints. Estoy seguro de que tus habilidades y conocimientos serán valiosos en el futuro y te permitirán abordar problemas cada vez más complejos con éxito.